Skip to content

hrx: score each key once above 2048 keys, instead of on every value lane (engine #991) - #214

Merged
bong-water-water-bong merged 1 commit into
1bit/hrx-vulkan-patchedfrom
1bit/hrx-rows-blocked
Nov 6, 2026
Merged

bong-water-water-bong merged 1 commit into
1bit/hrx-vulkan-patchedfrom
1bit/hrx-rows-blocked

Conversation

@bong-water-water-bong

Copy link
Copy Markdown

hrx: score each key once above 2048 keys, instead of on every value lane (engine ggml-org#991)

Part of engine ggml-org#991, and the follow-up to #213: that one put Zamba-7B-v1's decode attention fully on HRX; this one removes a
15× prefill cliff that every long-context model on this box was paying for.

The bug

common.attention_strided_f32_f16 (dispatch-small-rows.cpp) picks its kernel by context length:

rows = nkv <= 2048 && d <= 256 && dv <= 256    ->  ggml_attention_rows_f32_f16
else                                          ->  ggml_attention_strided_f32_f16

The rows kernel scores each key once and keeps the whole 2048-entry score row in workgroup memory, which is exactly what limits it
to 2048 keys. Past that, every node fell to the strided kernel: workgroup size = dv, one lane per value dimension, and every lane
recomputes the whole q·k dot product for every key
, keeping only its own share of the result. That is nkv·dv·d instead of
nkv·(d + dv) — 80× the arithmetic at d = dv = 160, 112× at 224.

Measured A/B on one binary, Zamba2-2.7B BF16, -p 512,2048,2560,4096:

pp default matcher ONEBIT_HRX_ATTN_PER_LANE=1 (strided at every length)
512 395.38 t/s 87.48 t/s
2048 270.59 (0.684 of pp512) 37.62
2560 72.19 (0.183) 31.00
4096 25.69 (0.065) 19.95

The second column is the point: at 512 tokens, with the same shapes and the same arithmetic, the strided kernel costs 78 % of the
throughput, so the kernel and not the context length is the cost. The cliff is exactly at the 2048-key switch — 0.684 → 0.183 between
two neighbouring lengths. This is the mechanism behind the collapse another lane measured across four Zyphra quants (0.049-0.073 of
pp512 at -p 4096); Zamba-7B-v1 was unaffected only because its 464-wide value head is refused by this registration entirely.

The change (4 files, +354/−3)

  • ops/small_rows_f32.loom: new export ggml_attention_rows_blocked_f32_f16, the rows kernel walking the keys in blocks of 2048
    with the flash-attention online softmax — running max and sum carried across blocks, a dv-wide output row in workgroup memory
    rescaled by exp(old_max − new_max) per block, then accumulated. Workgroup memory: 1 KB query row + 8 KB score row + 1 KB output row
    = 10 KB of the 64 KB budget (10256 B in the compiled hsaco).
  • Keys are padded to whole blocks by clamping the key index (index.min %j0, %last_key) and forcing the padded score to the lowest
    f32, so their probability is exactly zero in both the sum and the value pass, and no load can leave the buffer.
  • ggml_attention_rows_f32_f16 is untouched: 2048 keys or fewer keeps the old kernel, so nothing below the switch changes and the
    A/B above is attributable to this change alone. strided stays for d or dv above 256, the head sizes only it admits.
  • manifest.json: one export entry (mechanical copy of the rows entry; the catalog static_assert in GGML_HRX_KERNEL_REF
    resolving at build is the well-formedness check).
  • dispatch-small-rows.cpp: three-way selection, with the measured numbers in the comment so nobody has to re-derive why strided
    exists at all.
  • tests/test-backend-ops.cpp: six new FLASH_ATTN_EXT cases at kv = 2560, 4096 × hsv = 64, 160, 256 (160 is Zamba2's own
    head size). The suite's longest existing case was exactly 2048 — the last length that still took the rows kernel — which is why no
    gate ever saw this.
    These are the first cases above 2048 keys.

Why the padding/index clamp rather than min(nkv − block_start, 2048) as the loop bound

Because loom cannot invert a subtraction, so block_start + slot < nkv was not provable and the JIT refused the kernel outright:

SUBRANGE/024: view.load scalar footprint upper bound is not proven on view axis 0; origin %159 must satisfy
origin + 1 <= view_bound for view %110; view_bound is 2457600, and the maximum legal origin is 2457599

— on the key, mask and value loads. index.min bounds its result, not the algebra around it, so a clamp states the bound as a fact the
compiler can use. (This is also why the first version failed at kv=2560 but passed at 4096: at 4096 every block is full, so the
bound was trivially constant.)

Worth knowing for reviewers of any loom change: a refused kernel is a hard failed to prepare command, not a CPU fallback, and one
such kernel id takes the whole model down (failed to run prompt warmup). The diagnosis above came from ggml-hrx-compile-kernel,
which compiles a single specialization on the CPU from the text loom source — no GPU ticket, seconds per iteration instead of ~40
minutes queued. I verified the fixed kernel there at kv 2560/3000/4096/5000 with zero diagnostics before rebuilding.

Device evidence (gfx1151, pi-zarch-rb-blocked2, gpu-hold, 166 s on the token)

  • all six new cases: OK against the CPU reference (tolerance 5e-4 nMSE), including hsv=160 and both kv values;
  • zero Loom compilation failed anywhere in the run (now a hard assertion in the harness, because that failure is silent otherwise);
  • chosen specialization is deterministic: 1024 workgroups × 64 lanes, group_segment_fixed_size 10256 B;
  • Zamba2-2.7B, same binary, -p 512,2048,2560,4096 -r 2:
pp before after
512 395.38 369.56
2048 270.59 275.54
2560 72.19 190.93 (2.6×)
4096 25.69 131.28 (5.1×)

pp4096/pp512 goes 0.065 → 0.355. The ≤2048 path is byte-identical code, so the pp512/pp2048 differences are measurement noise on a
box other lanes were using, not a claim of improvement — the prefill rows above 2048 are the claim. What remains of the decline with
context (0.746 → 0.517 → 0.355) is the honest linear attention cost, not a cliff.

Not in this PR

A tail-block optimisation: a block that is 25 % full still scans all 2048 slots (that padding is what makes the bounds provable, and its
cost is at most one padded block per (query, head)). The prefill V-tail for the other attention kernel (flash_attention_f32_f16_wmma,
which publishes whole 64-wide tiles) is separate kernel work; and that file's value predicate caps at
kPrefillMaxHeadSize = 576 while both ops declare range(value_head_size, 64, 512), so V = 576 is claimed and then JIT-rejected —
unreachable on this box (no model has V = 576) and left for the prefill work rather than as review churn here.

…ane (engine ggml-org#991)

Zamba2-2.7B's prefill collapsed to 0.065 of its pp512 throughput at -p 4096, and the cause was a kernel
selection cliff rather than the model or the graph: `common.attention_strided_f32_f16` picks its kernel by
context length, and past 2048 keys the rows kernel can no longer keep its score row in workgroup memory, so
every FLASH_ATTN_EXT node fell to the strided kernel, whose workgroup has one lane per value dimension and
each of those lanes recomputes the whole q.k dot product for every key - nkv*dv*d instead of nkv*(d + dv),
which is 80x the arithmetic at d = dv = 160 and worse at 224.

Measured on one binary, Zamba2-2.7B BF16, `-p 512,2048,2560,4096`:

  default matcher                          pp512 395.38   pp2048 270.59 (0.684)   pp2560 72.19 (0.183)   pp4096 25.69 (0.065)
  ONEBIT_HRX_ATTN_PER_LANE=1 (strided)     pp512  87.48

The second row is the point: at 512 tokens, with the same shapes and the same arithmetic, the strided kernel
costs 78% of the throughput, so the kernel and not the context length is the cost. The cliff is exactly at
the 2048-key switch (0.684 -> 0.183 between neighbouring lengths).

This adds `ggml_attention_rows_blocked_f32_f16`: the rows kernel walking the keys in blocks of 2048 with the
flash-attention online softmax - running max and sum carried across blocks, a dv-wide output row in
workgroup memory rescaled by exp(old_max - new_max) per block, 1 KB query row + 8 KB score row + 1 KB output
row. Keys are padded to whole blocks by clamping the key index and forcing the padded score to the lowest
f32, so their probability is exactly zero in both the sum and the value pass and no load ever leaves the
buffer. `ggml_attention_rows_f32_f16` is untouched: 2048 keys or fewer keeps the old kernel, so nothing below
the switch changes and the A/B above is attributable.

Why clamping instead of `min(nkv - block_start, 2048)` as the loop bound: loom cannot invert a subtraction, so
`block_start + slot < nkv` was not provable and the JIT refused the kernel outright with SUBRANGE/024 on the
key, mask and value loads - a hard `failed to prepare command`, not a CPU fallback. `index.min %j0,
%last_key` states the bound as a fact about a min result, which is what the compiler can use. Found with
ggml-hrx-compile-kernel, which compiles a single specialization on the CPU from the text loom source, so the
diagnosis took seconds instead of a GPU ticket; verified there at kv 2560/3000/4096/5000 before rebuilding.

Cases: six new FLASH_ATTN_EXT cases at kv 2560 and 4096 (hsv 64, 160 and 256; 160 is Zamba2's own head size).
The suite's longest case was exactly 2048 - the last length that still took the rows kernel - which is why no
gate ever saw this. On gfx1151 all six pass against the CPU reference, the chosen specialization is
deterministic (1024 workgroups x 64 lanes, 10256 B of workgroup memory), and the model then measures
pp2560 72.19 -> 190.93 and pp4096 25.69 -> 131.28 with the <= 2048 path unchanged. The remaining decline with
context is the honest O(nkv) attention cost, not a cliff.
@bong-water-water-bong

Copy link
Copy Markdown
Author

#214 ready for review at ffef39f18 (base dab5f8986, i.e. on top of the just-merged #213; 4 files, +354/−3; ls-remote confirms).

Three things a reviewer should get without digging:

  1. This is the fix for the long-prefill collapse another lane measured across four Zyphra quants at -p 4096. The mechanism is a
    kernel-selection cliff at 2048 keys, not the model and not the graph: past 2048 keys the strided attention kernel recomputes the whole
    q·k dot product on every value lane (nkv·dv·d instead of nkv·(d + dv)). The A/B in the body is on one binary with the same shapes at
    512 tokens, so the kernel is shown to be the cost rather than the context length.
  2. ggml_attention_rows_f32_f16 is untouched. 2048 keys or fewer keeps the old kernel, so nothing below the switch changes and the
    numbers are attributable. strided stays for d/dv > 256.
  3. The new suite cases are the point, not decoration: the longest FLASH_ATTN_EXT case was exactly 2048 — the last length that still
    took the rows kernel — so no gate had ever exercised this path. The six new cases (kv 2560/4096 × hsv 64/160/256) pass against the CPU
    reference on gfx1151, and the harness now fails on any Loom compilation failed, because that failure is otherwise silent and takes the
    whole model down.

For anyone doing loom work: I hit SUBRANGE/024 (footprint not provable on the key/mask/value loads) because the loop bound was
min(nkv - block_start, 2048) and loom cannot invert a subtraction; clamping the index with index.min %j0, %last_key and zeroing the
padded lanes' probability is what made it provable. That, and ggml-hrx-compile-kernel compiling a single specialization on the CPU from
the text .loom (no GPU ticket, seconds per iteration), is written up in ~/briefs/TNOTE-zarch-loom-offline-oracle.md.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at ffef39f18 on the parts that can be proved without a device — the six new cases exist and pass on the CPU reference, the dispatch three-way is right, the new kernel's online-softmax/padding scheme is sound and follows the file's existing conventions — and the HRX numbers stay the author's, for the reason at the bottom.

The new cases are real and green here. Built at this head (GGML_HRX=OFF, CPU), then:

./bin/test-backend-ops test -o FLASH_ATTN_EXT -b CPU   ->  5149/5149 tests passed, Backend CPU: OK
FLASH_ATTN_EXT(hsk=64,hsv=64,nh=8,nr23=[4,1],kv=2560,nb=32,…,type_K=f16,type_V=f16): OK
FLASH_ATTN_EXT(hsk=160,hsv=160,…kv=2560…): OK        (160 is Zamba2's own head size)
FLASH_ATTN_EXT(hsk=256,hsv=256,…kv=2560…): OK
FLASH_ATTN_EXT(hsk=160,hsv=160,…kv=4096…): OK        (+ the kv=4096 hs=64/256 pair from the same loop)

so the shapes are accepted by the op, the reference agrees, and the case set is not decorative. The claim that motivated them also checks out at the base: in tests/test-backend-ops.cpp at base214 the FLASH_ATTN_EXT kv list is {1024, 2048} — 2048 is the largest, i.e. exactly the last length that still took the rows kernel, which is why no gate ever saw the cliff.

The dispatch cannot change anything at or below 2048 keys. Three-way, and the old branch is preserved verbatim for the range that had it:

per_lane || d > 256 || dv > 256          -> strided     (unchanged, and still the escape hatch)
else  nkv <= 2048                        -> rows        (untouched kernel, so the A/B is attributable)
else                                     -> rows_blocked

The blocked kernel's own index.assume bounds (d, dv in [1,256]) are the same ones the shipped rows kernel already carries and the same convention every kernel in this file uses for its maximum (range(%token_count, 1, 2048) with 2048-token launches), so nothing new is being asserted to the compiler at the top of the range.

The kernel's correctness argument holds when read against the code: per 2048-key block it computes scores, takes the block max, folds it into the running max, rescales the running sum and the running output row by exp(old_max − new_max), accumulates p·v from workgroup memory, and divides by the final sum once at the end — the standard online-softmax recurrence, with o_row explicitly zero-initialised first (the comment says why: the first rescale multiplies a defined value). Padding is done by clamping the key index (index.min %j0, %last_key) and forcing the padded score to the lowest f32, so the padded slots' probability is exactly zero in both the sum and the value pass; the mask load uses the clamped index too, and its value cannot matter because the score is overwritten. Workgroup memory is 1 KB query + 8 KB scores + 1 KB output = 10 KB of the 64 KB budget, and the barriers are where they have to be: after the query/output init, and between the score pass and the value pass (the value pass reads all 2048 scores, not just its own lane's strided subset).

What I did not run: the HRX side — the blocked kernel on the device, the A/B table (395.38 → 87.48 t/s at 512 with ONEBIT_HRX_ATTN_PER_LANE=1, 270.59 → 72.19 at 2560, 25.69 at 4096) and the claim that the result is bit-comparable to the rows kernel at lengths that fit. Those need GGML_HRX=ON, which on this box means an ExternalProject build of hrx + loom + IREE, and then a GPU window: tonight's c31 gate hold (TISSUES-ALL rule 1) excludes both. I would rather hand the queue an honest partial than hold a kernel PR for an hour of build. The re-derivation for whoever has the window is one command pair: test-backend-ops -b HRX0 -o FLASH_ATTN_EXT on this head (the six cases above are the ones that exercise it), then the same with ONEBIT_HRX_ATTN_PER_LANE=1 to reproduce the cliff.

One nit, style only: the dispatch comment carries the measured A/B numbers inline ("pp2048/pp512 = 0.68, pp2560 = 0.18, pp4096 = 0.065"), which is good, but the numbers are from Zamba2-2.7B and the comment does not say so where they could be mistaken for this box's general law — one clause naming the model would keep the next reader from generalising them.

@bong-water-water-bong

Copy link
Copy Markdown
Author

Post-merge verification on the merged base a3e8d42c9 (which now carries #214, #218, #226, #228, #229), reported because these are the two
things a review cannot see from the diff.

1. Correctness: PASS at the merged base. All six kv = 2560/4096 × hsv 64/160/256 cases are SUPPORTED and OK against the CPU
reference, exit=0, zero JIT diagnostics, and the dispatch log shows common.attention_strided_f32_f16 claiming them. So the change
survives #226's ggml-hrx memory work rather than merely having worked before it.

2. The speed claim stays on the pre-merge measurement, and I am not going to dress that up. Two re-runs at the merged base were starved:
the first measured pp512 25.6 tok/s and the second 113.6 against 369-441 in the original run, so in the first case my ratio gates would have
"passed" an 8× win that was entirely a collapsed denominator, and in the second pp2560 came out slower than pp4096. I added an explicit
pp512 baseline gate to the harness (< 200 tok/s → "starved, no ratio is meaningful", a failure not a pass) and replayed it against both runs;
it now rejects them. Both attempts cost 1.6 h and 2.8 h of queue, so I stopped rather than keep spending other lanes' slots. The numbers in the
description (pp2560 72.19 → 190.93, pp4096 25.69 → 131.28) remain measured on the tree the PR was written against; ~/work/zarch/proof/m214-bench.sh
re-runs them with the gate when the die is quiet.

3. One thing this surfaced about the base, filed as engine ggml-org#1316: at the merged base the token-embedding GET_ROWS is no longer placed on
HRX — "embd" is GET_ROWS f32 ne=[2560,2,1,1] ... 1 of 3024 nodes off (GET_ROWS x 1), 2 splits — in both the merged and adapter forms, where
the pre-move base was 0 nodes off on all four arms. Not this PR's change (the blocked kernel's six cases pass; the residue is identical with and
without it), but it means "0 nodes off HRX" is no longer true for Zamba2 at -ngl 99 on the current base, and that is worth knowing before anyone
quotes a placement number from it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant